CSV import: explicit column-to-property mapping - #26
Conversation
…n plugin with import CSV syntax
…teCsvNodes instead
The POST /api/projects/{id}/csv-import endpoint materialized a CSV into
a standalone .metamodel/.model file pair using metamodel inference.
That workflow is superseded by the import CSV {} syntax, which reads
CSV data directly at render/execution time against an existing,
manually authored metamodel. Removes CsvImportService, CsvImportRoutes,
the now-unused CsvModelInference engine and its tests, the backend's
:metamodel dependency (no longer needed), and the unused
CSV_IMPORT_FAILED error code.
The import CSV {} syntax was hardcoded directly into the base model
grammar. Model now exposes a generic extension point (imports:
[BaseModelImport], mirroring Config's sections: [BaseConfigSection]),
and CSV is implemented as a real contribution plugin using the same
SerializedGrammar-based merging approach as config/config-mdeo:
- ModelContributionPlugin now carries a serialized grammar and import
metadata instead of raw parser rule objects, so it can cross service
boundaries as plain JSON
- resolveModelPlugins deserializes and wraps each contributed import in
a BaseModelImport-extending rule, and createModelRule builds the root
Model rule from whatever imports are actually resolved
- language-model-csv now builds a real contribution plugin (external
references for Class/ID/STRING/NEWLINE so its serialized grammar
doesn't duplicate definitions already provided by the host)
- New service-model-csv microservice mirrors service-config-mdeo:
registers the CSV contribution with the model language and hosts its
standalone generated language service
- Deleted the dead mutable-registry scaffolding (registerModelContributionPlugin)
that caused the original ESM dual-instance bug, and the orphaned files
left over from the previous abandoned attempt
- modelScopeProvider's class-reference resolution is now plugin-agnostic
(dispatches on the "class" property generically instead of hardcoding
CsvClassImport by type)
- Fixed a pre-existing bug in modelDataHandler where the CSV file path
was passed as a full URI to an API expecting a plain relative path
Verified end to end: grammar parses with no errors, live diagram
rendering from CSV still works, and the model-data execution pipeline
correctly resolves CSV-backed instances.
npm ci in CI failed because these two new packages, added as part of the contribution-plugin refactor, were never registered in the committed lock file.
|
No problem with the square brackets, but should this be noted as an issue with the main parsing algorithm, @nk-coding ? I must admit I don't fully understand what the issue is there, perhaps @amirrza777 you can explain in a bit more detail? |
- nginx: /plugin/csv/ location was missing the proxy_http_version,
forwarded headers, and cross-origin headers every other plugin
location has, breaking ESM module loading and cross-origin isolation
for the CSV plugin specifically.
- service-model: register the generated model language (.m_gen) that
was dropped when this service was rewritten for the CSV contribution
plugin refactor, even though the platform still produces .m_gen
files (e.g. optimizer solutions) and language-model still has full
support for it.
- modelDataHandler: a CSV import present in a .m file was replacing
all hand-authored objects/links instead of merging with them, so
mixing manually-authored data with CSV-imported data in the same
file silently lost the manual entries.
- Moved the quote-aware CSV row/field parser out of
language-model-csv (used by the backend import) into language-shared
so language-model's live diagram rendering (modelGModelFactory) can
use the same correct parser instead of a naive split(",")/split("\n")
that miscounts rows when a field contains a quoted comma or newline.
Verified live: mixed manual + CSV objects now render together in the
diagram (confirmed with a hand-authored Company instance alongside
CSV-imported Person rows), .m_gen is now registered in the plugin
manifest, and the /plugin/csv/ headers match the other plugin
locations.
|
Happy to go into more detail. The grammar looks like this (csvImportRules.ts): CsvImportContentRule: "{" many(or(add("imports", CsvClassImportRule), NEWLINE)) "}" The outer import block and the per-class mapping list are nested: one entry inside a many(or(...)) loop that keeps matching either another entry or a newline until it hits the closing }, and each entry itself optionally has its own { mappings } suffix. When both levels use the same {/}, the parser can't reliably tell whether a given } closes the entry's own mapping list or the whole outer block, since chevrotain's LL(k) parser commits to an interpretation using fixed lookahead rather than backtracking. In practice this showed up as the parser splitting one entry into two: the mapping list's own { got misread as the start of a brand new CsvClassImport, and the AST had a second, bogus import whose "class" was actually one of the mapping tokens, failing to resolve as a reference. Switching the mapping list to [ ] instead removes the ambiguity entirely, since chevrotain can now distinguish which closing bracket belongs to which nesting level by token type instead of guessing from context. @szschaler on whether this is a main-parsing-algorithm issue: not really, in the sense that this isn't a Langium/chevrotain bug. It's a known, general limitation of LL(k) parsers: reusing the same delimiter for two nested optional/repeated structures makes the grammar locally ambiguous, and the standard fix in any LL(k)-based grammar (not specific to this codebase) is exactly what I did here, giving each nesting level its own distinct delimiter. @nk-coding might want to weigh in if there's a broader pattern here worth documenting for future grammar additions, but I don't think it needs a separate issue against the parser itself. |
|
I really do not have a clue what is going on here |
Niklas asked to avoid square brackets and instead follow how other
DSLs in this codebase disambiguate two adjacent brace blocks in one
rule: an explicit keyword between them, not a different bracket type
(e.g. model-transformation's match { pattern } then { block }).
The explicit mapping list now reads:
Person from "file.csv" with {
"column" = property
}
instead of the previous square-bracket form. Verified live: two CSV
imports in the same block, the first with an explicit mapping, parse
correctly now with no ambiguity errors.
|
You're right, looked at how model-transformation handles this: match { pattern } then { block } uses two adjacent brace blocks disambiguated by the keyword between them ("then"), not by using a different bracket type. Applied the same technique here: the mapping list is now introduced by a "with" keyword instead of square brackets: Person from "file.csv" with { Verified live: two CSV imports in the same block, the first with an explicit mapping, parse correctly now with no ambiguity errors. Pushed. |
|
Why are we writing grammars in abstract syntax notation rather than as EBNF-y text files? I find the current code utterly unreadable. |
|
@szschaler this predates my work (it's the pattern Niklas set up for the whole codebase, e.g. the metamodel/model/model-transformation/config grammars all use it), so he can give the definitive answer, but here's my understanding of why. A normal Langium project defines its grammar in a static .langium EBNF file, compiled once at build time. This codebase's plugins need something a .langium file can't do: contribution plugins (like this CSV one) get loaded and merged into a host language's grammar (the Model language) at runtime, and which contributions are present isn't known until then. GrammarSerializer/GrammarDeserializer (in language-common) exist specifically to take a grammar built via createRule().as(...) and turn it into a plain JSON-like structure that can be shipped over the wire from a plugin's service and merged into the host grammar's rule set at runtime, something you can't do with a .langium file since Langium compiles that ahead of time into a fixed parser. So the abstract syntax builder isn't chosen for its own sake, it's a workaround for needing runtime-composable grammars, which is the whole point of the contribution-plugin architecture. I agree it's harder to read than an EBNF file, that trade-off is inherent to supporting plugins this way rather than something specific to the CSV grammar. |
|
Basically that abstract syntax is an internal TS DSL for what regular Langium projecta do via its external DSL |
|
Thanks. It's probably worth splitting this out as a separate issue: #30. I still don't understand why this forces us to have different parentheses, though. The rules for |
createCsvNodes only emitted a header label, so a CSV-imported instance rendered as a bare "Company_0 : Company" box with none of its data, while hand-authored objects showed their property assignments. The row data was read and then discarded. Columns are now matched to properties by name across the class' extension chain and rendered the same way hand-authored values are, using the same type interpretation the import itself applies so the diagram agrees with the imported result. Labels are read-only since edits could not be written back to the CSV file.
The contribution-plugin refactor stripped every doc comment out of modelTypes.ts, which was unintentional collateral rather than a deliberate change. Restored them and added comments for the two new declarations. Also removes infra/.DS_Store and infra/docker/.DS_Store from tracking. They match .gitignore already, so they were committed before the ignore rule applied to them.
The model service imported language-model-csv directly and ran the CSV import itself, so adding any further import format meant changing the model service, and the model service knew about a specific plugin's data format. Follows the approach the config service already uses for its sections. The model service now extracts the source text of each contributed import block and forwards it to the plugin's own language service via sendPluginRequest, then merges the returned instances and links with the hand-authored ones. The CSV parsing and file reading move into service-model-csv behind a new model-import request handler. Adds service-model-common with the request and response contract, mirroring service-config-common. Only parser errors fail the plugin-side parse, since the plugin's service has no metamodel document to link cross-references against; the model service has already validated those on the real document, so plugins read names from $refText. Also drops the hardcoded CSV entries from the model language's syntax keywords. Contribution plugins already declare these through additionalKeywords, which the workbench merges in.
# Conflicts: # platform/backend/build.gradle.kts # platform/common/src/main/kotlin/com/mdeo/common/model/ApiResult.kt
Brings in the import delegation refactor, so the explicit column mapping now travels with the import block's own text to service-model-csv rather than being read in the model service. The mapping is forwarded to importCsvEntries there. The diagram's CSV nodes honour the explicit mapping too, so a file whose column names do not match the metamodel still shows its values rather than appearing empty.
The mapping block was introduced by a "with" keyword on the grounds that
reusing braces made the grammar ambiguous. That was wrong. Tested against the
composed grammar with the keyword removed: a file mixing a mapped and an
unmapped import parses with no errors and imports correctly, and the parser
reports no ambiguity.
The block can only start with "{", while the enclosing import list continues
with an ID or ends with "}", so one token of lookahead is enough. The
comparison drawn to the model-transformation grammar was also wrong: the rule
there separates two adjacent brace blocks, which is a different situation from
a single optional block after a string.
Uses plain nested braces, matching the nested blocks in the other DSLs.
|
You were right, and my earlier explanation was wrong. I tested it instead of reasoning about it this time. I removed the It parses with no errors, no ambiguity warnings from Chevrotain, and imports correctly (Person_0/Person_1 from the mapped file, Company_0/Company_1 from the unmapped one). So there is no ambiguity, and nothing forced a different delimiter. Your reasoning was the correct one: the mapping block can only start with The comparison I drew to I have pushed the change: the mapping now uses plain nested braces, which is also what @nk-coding originally asked for. I should not have justified a syntax change with a parser limitation I had not verified. Sorry for the detour. |
Row numbering restarted at zero for each import entry, so importing the same class from two files produced two instances called ClassName_0, two called ClassName_1, and so on. Links refer to instances by name, so a collision would silently attach a link to the wrong object. Each entry is now numbered from the count of rows already imported for its class. The diagram numbers its nodes the same way, so node names still match the imported data.
# Conflicts: # app/packages/language-model/src/features/diagram-server/modelGModelFactory.ts
|
Thanks for looking into this again and finding a fix. |
There was a problem hiding this comment.
Pull request overview
This PR extends the model CSV import to support an explicit column-to-property mapping and introduces a contribution-plugin-based architecture so the model service can delegate import parsing/execution to plugin services (CSV now implemented as a contribution).
Changes:
- Adds CSV and model-CSV plugin services (Docker + dev compose + nginx routing) and registers them as default plugins in dev.
- Introduces a generic “model import contribution plugin” mechanism in
language-modeland updatesservice-modelto forward import blocks to contribution plugins and merge returned instances/links. - Implements CSV parsing/import mapping logic plus diagram rendering for CSV-backed instances, and adds a minimal standalone
.csvlanguage plugin/service.
Reviewed changes
Copilot reviewed 50 out of 52 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| infra/docker/workbench/nginx.conf | Adds reverse-proxy routes for /plugin/csv/ and /plugin/model-csv/. |
| infra/docker/service-model-csv/Dockerfile | Builds and packages the model-csv plugin service image. |
| infra/docker/service-csv/Dockerfile | Builds and packages the csv language service image. |
| infra/docker-compose-dev.yaml | Wires new csv/model-csv services into dev, env vars, healthchecks, and ports. |
| app/tsconfig.build.json | Adds TS project references for new language/service packages. |
| app/packages/service-model/tsconfig.json | Adds reference to service-model-common and reformats config. |
| app/packages/service-model/src/index.ts | Updates model service plugin metadata and token keywords handling for contributed imports. |
| app/packages/service-model/src/handler/modelDataHandler.ts | Delegates contributed imports to plugin services and merges imported data into ModelData. |
| app/packages/service-model/package.json | Adds dependencies needed for delegated import handling. |
| app/packages/service-model-csv/vite.config.ts | Builds ESM language.js static entry for the model-csv service. |
| app/packages/service-model-csv/tsconfig.json | TS config for the model-csv service package. |
| app/packages/service-model-csv/src/served/language.ts | Serves modelCsvPluginProvider for the language server to import. |
| app/packages/service-model-csv/src/index.ts | Registers model-csv service + contribution plugin and request handler wiring. |
| app/packages/service-model-csv/src/handler/csvImportRequestHandler.ts | Handles forwarded model-plugin requests, reads CSV files, returns instances/links/warnings. |
| app/packages/service-model-csv/package.json | Declares dependencies and build scripts for model-csv service. |
| app/packages/service-model-common/tsconfig.json | TS config for new shared model-plugin request/response types. |
| app/packages/service-model-common/src/modelPluginTypes.ts | Defines metamodel info + model plugin request/response payload contracts. |
| app/packages/service-model-common/src/modelPluginHandler.ts | Builds synthetic partial documents for contribution plugins to parse. |
| app/packages/service-model-common/src/index.ts | Exports model plugin handler + types. |
| app/packages/service-model-common/package.json | Declares new @mdeo/service-model-common package. |
| app/packages/service-csv/vite.config.ts | Builds ESM language.js static entry for the csv service. |
| app/packages/service-csv/tsconfig.json | TS config for the csv service package. |
| app/packages/service-csv/src/served/language.ts | Serves csvPluginProvider for the language server to import. |
| app/packages/service-csv/src/index.ts | Registers CSV language plugin/service for .csv files. |
| app/packages/service-csv/package.json | Declares new @mdeo/service-csv package. |
| app/packages/language-shared/src/util/csv.ts | Adds RFC4180-ish CSV text parser utility. |
| app/packages/language-shared/src/index.ts | Re-exports CSV parsing utility. |
| app/packages/language-model/src/plugin/resolvePlugins.ts | Adds contribution plugin resolution + wrapper rule generation for import <Keyword> .... |
| app/packages/language-model/src/plugin/modelContributionPlugin.ts | Defines model contribution plugin contract (imports, grammar, deps, exports). |
| app/packages/language-model/src/modelPlugin.ts | Merges contributed import wrapper rules into the Model language root rule at plugin creation time. |
| app/packages/language-model/src/index.ts | Exports new contribution plugin APIs. |
| app/packages/language-model/src/grammar/modelTypes.ts | Adds BaseModelImport and Model.imports to support contributed imports in AST. |
| app/packages/language-model/src/grammar/modelRules.ts | Builds a dynamic Model root rule including contributed import alternatives. |
| app/packages/language-model/src/features/modelScopeProvider.ts | Extends class reference scoping to cover class refs inside contributed imports. |
| app/packages/language-model/src/features/diagram-server/modelMetadataManager.ts | Preserves metadata for CSV-rendered nodes when CSV import exists. |
| app/packages/language-model/src/features/diagram-server/modelGModelFactory.ts | Renders synthetic diagram nodes from CSV row data (plus mapping support). |
| app/packages/language-model-csv/tsconfig.json | TS config for language-model-csv. |
| app/packages/language-model-csv/src/plugin/modelCsvContributionPlugin.ts | Defines the CSV model contribution plugin and its serialized grammar. |
| app/packages/language-model-csv/src/index.ts | Exports language-model-csv public API. |
| app/packages/language-model-csv/src/grammar/csvImportTypes.ts | Defines CSV import AST types (class import + mappings). |
| app/packages/language-model-csv/src/grammar/csvImportRules.ts | Defines CSV import grammar rules (class import + mappings). |
| app/packages/language-model-csv/src/features/csvImport.ts | Implements CSV-to-model import execution with explicit mapping + warnings. |
| app/packages/language-model-csv/src/csvPlugin.ts | Provides standalone model-csv generated language plugin for parsing forwarded import blocks. |
| app/packages/language-model-csv/package.json | Declares new @mdeo/language-model-csv package. |
| app/packages/language-csv/tsconfig.json | TS config for language-csv. |
| app/packages/language-csv/src/index.ts | Exports language-csv public API. |
| app/packages/language-csv/src/grammar/csvTypes.ts | Defines CSV file AST type. |
| app/packages/language-csv/src/grammar/csvRules.ts | Defines CSV grammar (captures whole file as text). |
| app/packages/language-csv/src/csvPlugin.ts | Provides CSV language plugin provider. |
| app/packages/language-csv/package.json | Declares new @mdeo/language-csv package. |
| app/package-lock.json | Adds workspace links for new packages. |
| .gitignore | Adds .DS_Store ignore entry. |
Files not reviewed (1)
- app/package-lock.json: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const uri = resolveRelativePath(doc, entry.file ?? ""); | ||
| const csvContent = await this.modelState.languageServices.shared.workspace.FileSystemProvider.readFile(uri); | ||
| const rows = parseCsv(csvContent); |
There was a problem hiding this comment.
Partly right, and I want to be precise about which part, because the suggested change would break the path that actually runs.
Confirmed: langiumPool does bind EmptyFileSystemProvider for the backend services, and this read does not register a file dependency.
However ServerApi is bound only in service-common langiumPool, for the backend services. The workbench has no ServerApi at all, it supplies a real fileSystemProvider through lspFileSystem. The GLSP diagram server runs in the workbench, which is why CSV nodes do render with their values today. Switching this to ServerApi.readFile() would therefore break the environment where the diagram is actually built.
I also could not find a path where the backend builds the GModel. ModelDiagramModule is configured in postCreate, so it is registered in the services, but registration is not the same as a diagram session being served there. If that ever changes, this would silently drop every CSV node, because the catch is bare.
I have left the code as it is rather than make a speculative change, but I am happy to add a hybrid read, ServerApi when present and FileSystemProvider otherwise, if you would rather have it defensive now. Leaving this thread open for a view on that.
There was a problem hiding this comment.
this looks very weird to me
why not obtain the langium document and get the content from there?
but more importantly the language-model should know nothing about CSV itself
this again needs to be handled by making the a service in the virtual model-csv language handle all this
(this is also an issue already in the earlier PR, but I could understand if you do not want to go back for this)
There was a problem hiding this comment.
You are right, and your suggestion is better than what I defended. Raised as #35 with the details.
On reading the Langium document instead: that is the better answer and I should have reached for it. .csv is a registered language, so the document already exists and I can take the content from there rather than going to the file system at all. It also makes the Copilot point above moot, since it stops depending on which FileSystemProvider is bound.
On the larger point, I measured where the concept actually leaks:
| package | lines mentioning CSV |
|---|---|
editor-model |
0 |
protocol-model |
0 |
service-model |
2, both prose in one doc comment |
language-model |
64 |
So it is only language-model, and there it is real logic rather than stray references: createCsvNodes and its helpers, a CSV_ID_COLUMN constant, and the csv-node- prefix the metadata manager keys off.
The cause is that the grammar and the data computation both have a generic extension point, and the diagram never got one, so rendering reaches into the import by wrapper type name and parses the CSV itself. The fix is to give the diagram the same treatment, so a contribution plugin supplies the nodes for its own import and the CSV specifics move into language-model-csv.
The part that needs a decision is node identity: layout metadata is persisted per node id and the ids are currently csv-node-N, invented by the factory, so a generic point needs plugins to own stable ids without the host inventing a namespace per format. Written up in #35.
Happy to do it in this PR or as a follow up, whichever you prefer. Given you are ready to merge #23, my instinct is a follow up so this does not hold that up, but I am fine either way.
An empty .csv could not be parsed, because ANY_TEXT requires at least one character and the rule assigned it unconditionally. The CSV language sets newFileAction, so every file made with "Create New CSV" was in a parse error state until something was typed into it. The assignment is now optional, and the interface attribute with it. The terminal still requires a character, since a terminal that matches the empty string would not advance the lexer. ModelMetadataManager also passed the metadata under validation to extractGraphMetadata through an instance field, because the base signature only carries the source model. It is now restored in a finally block, so a nested or concurrent validation cannot observe another document's metadata.
|
maybe to make that thing a bit clearer: the concept "CSV" should not exist in language/editor/protocol/service-model |
Running the workbench outside Docker had no route to the two new plugin services, so they were unreachable in local dev even though nginx proxies them in the Docker setup. Vite matches proxy prefixes in declaration order, so model-csv has to come before model, the same way model-transformation already does, otherwise /plugin/model swallows it. Plain csv goes last. Added a comment recording that, since the ordering is not obvious from the list itself.
Relates to #22, stacked on #23 (needs the CSV import contribution plugin merged first).
What this adds
An optional explicit mapping block after the CSV file path:
Syntax note
An earlier version of this PR used square brackets, and this description used to claim that reusing curly braces caused a parsing ambiguity. That was wrong, and @szschaler was right to push back on it.
I tested it rather than reasoning about it: with the mapping block using plain nested braces, a file mixing a mapped and an unmapped import parses with no errors, no ambiguity warnings from Chevrotain, and imports correctly. The mapping block can only start with
{, while the enclosing import list either continues with an ID or ends with}, so a single token of lookahead is enough to tell them apart.The syntax is now plain nested braces, consistent with the nested blocks in the other DSLs, which is what @nk-coding asked for originally.
Draft until #23 is merged, since this branches from it.